Skip to content

Scope error-class throttling per-user for public-client flows - #1055

Open
Avery-Dunn wants to merge 3 commits into
devfrom
avdunn/throttling-fix
Open

Scope error-class throttling per-user for public-client flows#1055
Avery-Dunn wants to merge 3 commits into
devfrom
avdunn/throttling-fix

Conversation

@Avery-Dunn

@Avery-Dunn Avery-Dunn commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #1019, using the core fix made by Maximilian Pfeffer (@gterminator) in #1050 but avoiding an issue in 429 throttling scenarios, adds a couple extra tests, and allowing it to be run in our CI pipelines.

Problem

When a public-client app acquires tokens for multiple users under the same clientId / authority / scope, a failed request for one user could throttle every other user. Reported symptoms in #1019:

  • One user repeatedly submitting a bad password (ADFS/STS returns HTTP 500 for that user) caused all users of the app to be blocked with MsalThrottlingException.
  • Intermittent HTTP 500 responses from the token endpoint for one user blocked unrelated users.

Root cause

HttpHelper throttles public-client requests by a "request thumbprint". Before this change the thumbprint was derived from clientId + authority + scope only — it did not include any user component for flows like Username/Password (ROPC), where there is no Account yet. As a result two different users produced a byte-identical throttle key, so a 500 cached for user A immediately throttled user B.

Fix

Make the throttle key response-type-aware:

  • HTTP 5xx (error-class, and can be user-specific — e.g. a single user's bad password) → key per-user, folding the request's user component (OID, else UPN) into the thumbprint. One user's failure no longer blocks others.
  • HTTP 429 (service-directed rate limiting for the whole client) → key remains app-wide (no user component), preserving correct global back-off.

The scope is decided by the response status class, not by the presence of a Retry-After header. An explicit Retry-After only overrides the throttle duration; a 5xx that carries a Retry-After is still scoped per-user, and a 429 with Retry-After is still app-wide.

checkForThrottling now checks the app-wide key first, then the user-aware key when it differs; processThrottlingInstructions writes under the app-wide key for 429 and under the user-aware key for 5xx.

Files changed

  • msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java
    • getRequestThumbprint(RequestContext, boolean includeUser) overload. The user component prefers OID (stable, unique) and falls back to UPN only when no OID is available (e.g. Username/Password before an account exists).
    • checkForThrottling checks both app-wide and user-aware keys.
    • processThrottlingInstructions decides scope by status class: 5xx → user-aware, 429 → app-wide; a Retry-After header only sets the duration.

Behavior change

Trigger Before After
HTTP 5xx (e.g. bad password via ADFS) Throttles the whole client (all users) Throttles only that user
HTTP 5xx with Retry-After Throttles the whole client (app-wide) Throttles only that user, for the header's duration
HTTP 429 (with or without Retry-After) App-wide App-wide (unchanged)
Auth-code / other flows without a user component App-wide App-wide (unchanged)

No public API change; no change to the cached throttle-entry value shape — only the key derivation changed. Throttle entries are transient (default window, capped by MAX_THROTTLING_TIME_SEC).

Tests

msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RequestThrottlingTest.java:

  • UserNamePassword_DifferentUsersThrottledIndependently — user A's 500 throttles A but not user B (regression test for [Bug] Throttling cache affects whole clientId when user provides wrong password in ADFS federation #1019).
  • UserNamePassword_429ThrottlesDifferentUsersAppWide — a 429 still throttles a different user, proving 429 stays app-wide (guards against over-narrowing).
  • UserNamePassword_500WithRetryAfterThrottlesUsersIndependently — a 5xx carrying a Retry-After header is still scoped per-user (scope is decided by status class, not by the header).
  • STSResponseContains_StatusCode500_DifferentUsersNotThrottledForEachOther and SilentFlow_DifferentAccountsThrottledIndependently — end-to-end and silent-flow isolation.
  • All existing throttling tests (RETRY_AFTER_HEADER, 429, 500, combinations) continue to pass unchanged.

@gterminator

Copy link
Copy Markdown
Contributor

Avery-Dunn I'm not sure about this change. What happens when there is some gateway timeout? Or other 5xx messages?

I mean we have to check for the user case that we have a 5xx AND a retry in the header, otherwise we could get some delays in that we dont want e.g. on 502 Bad Gateway for example.

Btw. What happens when we retry? Do we consume the exception and automatically retry?

Comment thread msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts MSAL4J’s public-client request throttling so that HTTP 5xx “error-class” throttling is keyed per user, while HTTP 429 / explicit Retry-After throttling remains app-wide, preventing one user’s failures (e.g., bad password causing ADFS 500) from throttling other users sharing the same clientId/authority/scope.

Changes:

  • Introduces app-wide vs user-aware throttle thumbprints and updates throttling read/write logic accordingly.
  • Updates throttling checks to consult the app-wide key first, then the user-aware key when different.
  • Adds regression and isolation tests for per-user 5xx throttling and app-wide 429 throttling.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java Adds user-aware thumbprint support and routes throttling behavior based on response type (5xx vs 429/Retry-After).
msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RequestThrottlingTest.java Adds regression tests ensuring 5xx throttling is per-user while 429 remains app-wide.

Comment thread msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java
Comment on lines +215 to +219
@Test
void STSResponseContains_StatusCode500_DifferentUsersNotThrottledForEachOther() throws Exception {
skipInvocationCountCheck = true;
ThrottlingCache.clear();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit

@gterminator

Copy link
Copy Markdown
Contributor

Avery-Dunn can we maybe pin this special behavior to a special status code?

I want to avoid that you get DDOS-ed when some Auth Server is on bad gateway or has a error.

@Avery-Dunn

Copy link
Copy Markdown
Contributor Author

Avery-Dunn I'm not sure about this change. What happens when there is some gateway timeout? Or other 5xx messages?

I mean we have to check for the user case that we have a 5xx AND a retry in the header, otherwise we could get some delays in that we dont want e.g. on 502 Bad Gateway for example.

Btw. What happens when we retry? Do we consume the exception and automatically retry?

"Do we consume the exception and auto-retry?": No, in most flows we use DefaultRetryPolicy.isRetryable  = 5xx, so a bare 5xx is auto-retried once (1s) on the raw IHttpResponse, before any throttle is written or exception created. Throttling records the final response after those retries, so a transient 502/504 that recovers on retry is never throttled.

"5xx AND a Retry-After header": This was a gap in my original proposed changes, but in the latest commit it should be more consistent. The old code checked the Retry-After branch first, so a 5xx+Retry-After was throttled app-wide and it would still cause the issue in #1019. In the latest commit whether or to to throttle is now decided by status class (5xx: per-user, 429: app-wide), and Retry-After only determines the duration in either case.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java:158

  • The user-aware thumbprint uses the raw UPN string as part of the hash input. UPNs are typically case-insensitive, so different casing for the same user (e.g., User@contoso.com vs user@contoso.com) would produce different throttle keys and may bypass per-user throttling. Normalize the UPN (e.g., lower-case with Locale.ROOT) before hashing to keep the key stable.
                // Prefer OID: it is the stable, guaranteed-unique user identifier
                if (!StringHelper.isBlank(userIdentifier.oid())) {
                    sb.append(userIdentifier.oid()).append(POINT_DELIMITER);
                } else if (!StringHelper.isBlank(userIdentifier.upn())) {
                    sb.append(userIdentifier.upn()).append(POINT_DELIMITER);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Throttling cache affects whole clientId when user provides wrong password in ADFS federation

5 participants